{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<center>\n",
    "<img src=\"../../img/ods_stickers.jpg\">\n",
    "## Открытый курс по машинному обучению\n",
    "<center>Автор материала: Лазарев Александр Александрович (@alexander_lazarev)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# <center>Novelty Detection при классификации изображений</center>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "Можно найти много информации о принципах работы сверточных нейронных сетей, о том как можно благодаря буквально нескольким строчкам кода и небольшому набору данных создать свою модель, которая будет отличать котиков от собачек и тд. Но когда дело доходит до реальной задачи, возникает масса вопросов на которые гугл не может дать четких ответов. \n",
    "\n",
    "С одним из таких вопросов я столкнулся во время <strike>чего-то</strike> разработки своего [приложения](https://plants-care.com) для распознавания видов растений. Проблема заключалась в следующем - как быстро и эффективно отличить распознаваемое изображение и его отношение к тому на чем обучалась модель. Например, если мы обучали на котиках и собачках, то как отличить вентилятор от этих животных? Мы бы могли добавить еще один класс для вентиляторов, переобучить модель и начать отличать их, но вод беда - объектов которые не относятся к котикам и собачкам великое множество и мы не можем каждый раз добавлять класс хотя бы по следующим причина причинам: бесконечное количество потенциальных классов; сбор данных для обучения нового класса достаточно трудоемкий процесс; переобучение модели занимает время и ресурсы, а при имении порядочного количества данных и классов это время на вес золота; с ростом классов точность модели падает.\n",
    "\n",
    "После некоторых раздумий первое, что пришло на ум - попробовать посмотреть, что происходит с активациями нейронов на последних слоях сети. Берем последние потому, что начальные слои содержат достаточно мало абстрактной информации. Мое интуитивное понимание заключалось в том, что скорее всего на неизвестных объектах сеть должна возбуждаться меньше и соответственно это как-то можно замерять простыми способами.\n",
    "\n",
    "Давайте поэтапно разберем задачу и проблему."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Условия"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "За основу мы возьмем предобученную модель Resnet50 и будем ее [файн-тюнить](https://blog.keras.io/building-powerful-image-classification-models-using-very-little-data.html) на котиках и собачках взятых на [каггле](https://www.kaggle.com/c/dogs-vs-cats/data). В тренировочном датасете лежит по 12500 картинок каждого класса, но нам потребуется всего 1000 (этого достаточно чтобы получить хорошую точность). Подготовленные данные использованные в данном туториале проще скачать [здесь](https://www.dropbox.com/s/is44kutatj0e9fy/mlcourse_tutorial_data.zip?dl=0).\n",
    "\n",
    "Для реализации из основных библиотек нам потребуется: \n",
    "- Keras (Keras версии 1.2 так-как во второй беда с весами под Resnet50 для Theano)\n",
    "- Theano\n",
    "- sklearn\n",
    "- pandas\n",
    "- numpy"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Импорт необходимых библиотек"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import glob\n",
    "import os\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "from keras import backend as K\n",
    "from keras.applications.imagenet_utils import preprocess_input\n",
    "from keras.applications.resnet50 import ResNet50\n",
    "from keras.layers import Dense, Dropout, Flatten, Input\n",
    "from keras.models import Model, Sequential\n",
    "from keras.optimizers import SGD\n",
    "from keras.preprocessing import image\n",
    "from keras.preprocessing.image import ImageDataGenerator\n",
    "from sklearn.cross_validation import StratifiedShuffleSplit\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.model_selection import GridSearchCV\n",
    "from sklearn.preprocessing import LabelEncoder"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Создание модели\n",
    "\n",
    "В Keras уже есть готовый модуль который содержит известную ResNet50. Все что нам нужно - это воспользоваться ею. Параметр **include_top=False** отвечает за то, что нам вернеться архитектура модели, но без последних слоев. Из-за того, что мы здесь занимаемся трансфером знаний предобученой сети, нужно прикрутить самим последние слои (я не буду описывать как работает fine-tuning так как это не есть целью даного туториала).\n",
    "\n",
    "Важным моментом в прикручивании своих слоев для нашей задачи являеться добавление дополнительного Dense(2048) слоя. Если бы мы просто файнтюнили, этот слой нам бы не помог в точности, а наоборот чуть ухудшил ее, но именно он является самым полезным в снятии активаций для дальнейшего анализа. Как раз он получает максимум абстрактной полезной информации."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "NB_EPOCH = 20\n",
    "\n",
    "RELEVANT_LAYER_NAME = 'relevant_layer'\n",
    "IMG_SIZE = (224, 224)\n",
    "\n",
    "NB_VAL_SAMPLES = 200\n",
    "NB_TRAIN_SAMPLES = 800\n",
    "\n",
    "TRAIN_DIR = 'data/train/'\n",
    "VALID_DIR = 'data/valid/'"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def create_model():\n",
    "        base_model = ResNet50(include_top=False, input_tensor=Input(shape=(3,) + IMG_SIZE))\n",
    "\n",
    "        # делаем так чтобы слои из основной модели не тренировались\n",
    "        for layer in base_model.layers:\n",
    "            layer.trainable = False\n",
    "\n",
    "        x = base_model.output\n",
    "        x = Flatten()(x)\n",
    "        x = Dropout(0.5)(x)\n",
    "        # слой с которого мы будем снимать значения активаций нейронов\n",
    "        x = Dense(2048, activation='elu', name=RELEVANT_LAYER_NAME)(x)\n",
    "        x = Dropout(0.5)(x)\n",
    "    \n",
    "        predictions = Dense(1, activation='sigmoid')(x)\n",
    "\n",
    "        return Model(input=base_model.input, output=predictions)\n",
    "    \n",
    "print(\"Creating model..\")\n",
    "model = create_model()\n",
    "print(\"Model created\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Файн-тюним"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def apply_mean(image_data_generator):\n",
    "    \"\"\"Subtracts the dataset mean\"\"\"\n",
    "    image_data_generator.mean = np.array([103.939, 116.779, 123.68], dtype=np.float32).reshape((3, 1, 1))\n",
    "\n",
    "def get_train_datagen(*args, **kwargs):\n",
    "    idg = ImageDataGenerator(*args, **kwargs)\n",
    "    apply_mean(idg)\n",
    "    return idg.flow_from_directory(TRAIN_DIR, target_size=IMG_SIZE, class_mode='binary')\n",
    "\n",
    "def get_validation_datagen():\n",
    "    idg = ImageDataGenerator()\n",
    "    apply_mean(idg)\n",
    "    return idg.flow_from_directory(VALID_DIR, target_size=IMG_SIZE, class_mode='binary')\n",
    "    \n",
    "def fine_tuning(model):\n",
    "    # выбираем для дообучения 2 identity блока и 1 сверточный \n",
    "    # (можно эксперементировать изменяя значение 80 чтобы добиться лучших результатов)\n",
    "    # все слои выше - \"замораживаем\"\n",
    "    for layer in model.layers[:80]:\n",
    "        layer.trainable = False\n",
    "    for layer in model.layers[80:]:\n",
    "        layer.trainable = True\n",
    "\n",
    "    print(\"Compiling model..\")\n",
    "    sgd = SGD(lr=1e-4, decay=1e-6, momentum=0.9, nesterov=True)\n",
    "    model.compile(optimizer=sgd, loss='binary_crossentropy', metrics=['accuracy'])\n",
    "    model.fit_generator(\n",
    "        get_train_datagen(rotation_range=30., shear_range=0.2, zoom_range=0.2, horizontal_flip=True),\n",
    "        samples_per_epoch=NB_TRAIN_SAMPLES,\n",
    "        nb_epoch=NB_EPOCH,\n",
    "        validation_data=get_validation_datagen(),\n",
    "        nb_val_samples=NB_VAL_SAMPLES)\n",
    "    \n",
    "    \n",
    "fine_tuning(model)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Подготавливаем релевантные и нерелевантные данные"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В папке irrelevant я подготовил изображения, которые достаточно разные по содержимому и не относятся к нашим животным. Активации будем собирать используя валидационную выборку (так как модель не обучалась на ней) и выборку irrelevant."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_files(path):\n",
    "    files = []\n",
    "    if os.path.isdir(path):\n",
    "        files = glob.glob(path + '*.jpg')\n",
    "    elif path.find('*') > 0:\n",
    "        files = glob.glob(path)\n",
    "    else:\n",
    "        files = [path]\n",
    "\n",
    "    if not len(files):\n",
    "        print('No images found by the given path')\n",
    "\n",
    "    return files\n",
    "\n",
    "\n",
    "def load_img(img_path):\n",
    "    img = image.load_img(img_path, target_size=IMG_SIZE)\n",
    "    x = image.img_to_array(img)\n",
    "    x = np.expand_dims(x, axis=0)\n",
    "    return preprocess_input(x)[0]\n",
    "\n",
    "\n",
    "def get_inputs(files):\n",
    "    inputs = []\n",
    "    for i in files:\n",
    "        x = load_img(i)\n",
    "        inputs.append(x)\n",
    "    return inputs\n",
    "\n",
    "\n",
    "relevant_files = get_files('data/valid/**/*.jpg')\n",
    "print('Found {} relevant files'.format(len(relevant_files)))\n",
    "\n",
    "irrelevant_files = get_files('data/irrelevant/*.jpg')\n",
    "print('Found {} relevant files'.format(len(irrelevant_files)))\n",
    "\n",
    "relevant_inputs = get_inputs(relevant_files)\n",
    "irrelevant_inputs = get_inputs(irrelevant_files)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Извлекаем активации"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_activation_function(m, layer):\n",
    "    x = [m.layers[0].input, K.learning_phase()]\n",
    "    y = [m.get_layer(layer).output]\n",
    "    return K.function(x, y)\n",
    "\n",
    "\n",
    "def get_activations(model, inputs, layer, class_name):\n",
    "    all_activations = []\n",
    "    activation_function = get_activation_function(model, layer)\n",
    "    for i in range(len(inputs)):\n",
    "        activations = activation_function([[inputs[i]], 0])\n",
    "        all_activations.append(activations[0][0])\n",
    "\n",
    "    df = pd.DataFrame(all_activations)\n",
    "    df.insert(0, 'class', class_name)\n",
    "    df.reset_index()\n",
    "    return df\n",
    "\n",
    "irrelevant_activations = get_activations(model, irrelevant_inputs, RELEVANT_LAYER_NAME, 'irrelevant')\n",
    "relevant_activations = get_activations(model, relevant_inputs, RELEVANT_LAYER_NAME ,'relevant')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В итоге, имеем для каждого изображения 2048 значений. Эти значения ни что иное как активации нейронов нашего дополнительного слоя добавленного в ResNet50. То есть мы обучили модель, а потом на ней прогнали новые изображения собирая попутно полезные данные."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "irrelevant_activations.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "relevant_activations.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Интересный факт - сеть реагировала на незнакомые объекты бОльшим количеством нейронов нежели на знакомых.\n",
    "Вот что происходило:\n",
    "- для изображений использовавшихся при тренировке модели количество активированных нейронов находилось в диапазоне 19%-23% от общего количества;\n",
    "- для изображений находящихся в валидационной выборке - 20%-26%;\n",
    "- для иррелевантных изображений значение было 24%-28%."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Визуализация реагирования нейронов\n",
    "\n",
    "(Изображения взяты при исползовании модели VGG16 и слоя с 4096 нейронами)\n",
    "\n",
    "\n",
    "Активации для изображения на котором обучалась сеть\n",
    "<img src=\"https://habrastorage.org/web/6c6/513/0d5/6c65130d51794868b5d14c9bf3e3b2d2.jpg\"/>\n",
    "\n",
    "Активации для изображения из валидации\n",
    "<img src=\"https://habrastorage.org/web/99a/c8e/bcf/99ac8ebcf81440e6a1be86e0497570e2.jpg\"/>\n",
    "\n",
    "Активации для неизвестного изображения\n",
    "<img src=\"https://habrastorage.org/web/7e5/c67/d08/7e5c67d08e224e7587ca74908af70a15.jpg\"/>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Даже визуально можно заметить как хаос увеличиваеться с ростом неуверенности. Для меня это сравнимо толпе людей которые пытаються ответить на один вопрос и чем меньше они уверены в ответе, тем больше от них шума. "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Далее я подумал, а почему бы не попробовать эти данные прогнать через простенькую полносвязную сеть и решить проблему бинарной классификации:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def encode(df):\n",
    "    label_encoder = LabelEncoder().fit(df['class'])\n",
    "    labels = label_encoder.transform(df['class'])\n",
    "    df = df.drop(['class'], axis=1)\n",
    "    return df, labels\n",
    "\n",
    "df = pd.concat([irrelevant_activations, relevant_activations])\n",
    "X, y = encode(df)\n",
    "\n",
    "sss = StratifiedShuffleSplit(np.zeros(y.shape[0]), test_size=0.3, random_state=23)\n",
    "for train_index, test_index in sss:\n",
    "    X_train, X_test = X.values[train_index], X.values[test_index]\n",
    "    y_train, y_test = y[train_index], y[test_index]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model = Sequential()\n",
    "model.add(Dense(256, input_dim=2048, activation='elu', init='uniform'))\n",
    "model.add(Dropout(0.5))\n",
    "model.add(Dense(128, activation='relu', init='uniform'))\n",
    "model.add(Dropout(0.5))\n",
    "model.add(Dense(1, activation='sigmoid', init='uniform'))\n",
    "\n",
    "model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])\n",
    "model.fit(\n",
    "    X_train,\n",
    "    y_train,\n",
    "    nb_epoch=4,\n",
    "    validation_data=(X_test, y_test),\n",
    "    batch_size=16)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вуаля! На четвертой эпохе имеем почти 100% точность различаемости. А что если попробовать вместо нейронной сети самую обычную Logistic Regression?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.metrics import accuracy_score\n",
    "\n",
    "params = {'C': [10, 2, .9, .4, .1], 'tol': [0.0001, 0.001]}\n",
    "log_reg = LogisticRegression(solver='lbfgs', multi_class='multinomial', class_weight='balanced')\n",
    "clf = GridSearchCV(log_reg, params, scoring='neg_log_loss', refit=True, cv=3, n_jobs=-1)\n",
    "clf.fit(X_train, y_train)\n",
    "\n",
    "print(\"best params: \" + str(clf.best_params_))\n",
    "print('best score:'+ str(clf.best_score_))\n",
    "\n",
    "predictions = clf.predict(X_test)\n",
    "print(\"accuracy\", accuracy_score(y_test, predictions))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Что ж выходит и простой алгоритм способен дать очень высокую точность. На практике я отдал предпочтение LogisticRegression так как потребление памяти и вычислительных мощностей намного меньше. \n",
    "\n",
    "<u>Стоит учесть, что обучать модель для релевантности вам придеться каждый раз после переобучения главной модели, так как каждый последующий раз нейроны будут вести себя иначе.</u>\n",
    "\n",
    "В будущем планирую расписать это все более детально и обоснованно. Надеюсь, что этот туториал будет понятен и пригодиться вам на практике. Данный подход сработал отлично также для VGG16, InceptionV3. Думаю, сработает и для других топологий."
   ]
  }
 ],
 "metadata": {
  "anaconda-cloud": {},
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.1"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 1
}
